package org.dolphin.maven;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

import javax.persistence.Embeddable;
import javax.persistence.Entity;
import javax.persistence.MappedSuperclass;

import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.hibernate.cfg.Configuration;
import org.hibernate.cfg.Environment;
import org.hibernate.tool.hbm2ddl.SchemaExport;
import org.hibernate.tool.hbm2ddl.Target;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.core.type.classreading.MetadataReaderFactory;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.core.type.filter.TypeFilter;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

@Mojo(name = "export", defaultPhase = LifecyclePhase.COMPILE)
public class Hbm2DdlMojo extends AbstractMojo {
	private static final TypeFilter[] ENTITY_TYPE_FILTERS = {
			new AnnotationTypeFilter(Entity.class, false),
			new AnnotationTypeFilter(Embeddable.class, false),
			new AnnotationTypeFilter(MappedSuperclass.class, false) };
	private static final String PACKAGE_INFO_SUFFIX = ".package-info";
	@Parameter(property = "export.project", defaultValue = "${project}", readonly = true, required = true)
	private MavenProject project;
	@Parameter(property = "export.url")
	private String url;
	@Parameter(property = "export.user")
	private String user;
	@Parameter(property = "export.password")
	private String password;
	@Parameter(property = "export.format", defaultValue = "true")
	private boolean format;
	@Parameter(property = "export.delimiter", defaultValue = ";")
	private String delimiter;
	@Parameter(property = "export.driver")
	private String driver;
	@Parameter(property = "export.type", defaultValue = "BOTH", required = true)
	private String type;
	@Parameter(property = "export.target", defaultValue = "EXPORT", required = true)
	private String target;
	@Parameter(property = "export.dialect")
	private String dialect;
	@Parameter(property = "export.buildDirectory", defaultValue = "${project.build.directory}")
	private String buildDirectory;
	@Parameter(property = "export.hibernateProperties", defaultValue = "${project.build.outputDirectory}/hibernate.properties")
	private String hibernateProperties;
	@Parameter(property = "export.outputFile", defaultValue = "${project.build.directory}/schema-ddl.sql")
	private String outputFile;
	@Parameter(property = "export.packagesToScan", required = true)
	private String[] packagesToScan;

	protected Target convertSchemaExportTarget(String targetValue) throws MojoExecutionException {
		Target target = null;
		try {
			target = Target.valueOf(this.target.toUpperCase());
		} catch (IllegalArgumentException e) {
			getLog().error(
					"Invalid value of Target. Expected: "
							+ StringUtils.arrayToCommaDelimitedString(Target.values()) + ". Found: "
							+ targetValue);
			throw new MojoExecutionException("Invalid value for configuration-option \"target\"");
		}
		return target;
	}

	protected SchemaExport.Type convertSchemaExportType(String schemaExportType)
			throws MojoExecutionException {
		SchemaExport.Type type = null;
		try {
			type = SchemaExport.Type.valueOf(schemaExportType.toUpperCase());
		} catch (IllegalArgumentException e) {
			getLog().error(
					"Invalid value of Type. Expected: "
							+ StringUtils.arrayToCommaDelimitedString(SchemaExport.Type.values()) + ". Found: "
							+ type);
			getLog().error("Invalid value for configuration-option \"type\": " + this.type);
			throw new MojoExecutionException("Invalid value for configuration-option \"type\"");
		}
		return type;
	}

	@SuppressWarnings("unchecked")
	@Override
	public void execute() throws MojoExecutionException, MojoFailureException {
		ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
		ClassLoader classLoader = null;
		Set<Class<?>> classes = new LinkedHashSet<Class<?>>();
		Set<String> packages = new LinkedHashSet<String>();
		try {
			getLog().debug("Creating ClassLoader for project-dependencies...");
			List<String> classpathFiles = this.project.getCompileClasspathElements();
			int size = classpathFiles.size() + 1;
			Set<File> urlSet = new HashSet<File>(size);
			for (int i = 0; i < classpathFiles.size(); i++) {
				getLog().info("Dependency: " + classpathFiles.get(i));
				urlSet.add(new File(classpathFiles.get(i)));
			}
			URL[] urls = new URL[urlSet.size()];
			int idx = 0;
			for (File f : urlSet) {
				urls[(idx++)] = f.toURI().toURL();
			}
			classLoader = new URLClassLoader(urls, getClass().getClassLoader());

			getLog().debug("Changing current ClassLoader to project-dependencies ClassLoader");
			Thread.currentThread().setContextClassLoader(classLoader);
			scanAnnotatedClass(this.packagesToScan, classes, packages);

			Properties properties = loadHibernateProperties();
			overwriteProperties(properties);

			Configuration cfg = new Configuration();
			cfg.setProperties(properties);
			registerAnnotatedClass(cfg, packages, classes);

			SchemaExport.Type schemaExportType = convertSchemaExportType(this.type);
			Target schemaExportTarget = convertSchemaExportTarget(this.target);

			getLog().info("Gathered hibernate-configuration (turn on debugging for details):");
			for (Map.Entry<Object, Object> entry : properties.entrySet()) {
				getLog().info("  " + entry.getKey() + " = " + entry.getValue());
			}
			Connection connection = null;
			try {
				connection = loadConnection(properties, schemaExportTarget);
				SchemaExport export = connection != null ? new SchemaExport(cfg, connection)
						: new SchemaExport(cfg);
				export.setOutputFile(this.outputFile);
				export.setDelimiter(this.delimiter);
				export.setFormat(this.format);
				export.execute(schemaExportTarget, schemaExportType);
				List<Exception> exceptions = export.getExceptions();
				if (exceptions != null) {
					for (Exception ex : exceptions) {
						getLog().debug(ex.toString());
					}
				}
			} finally {
				try {
					if ((connection != null) && (!connection.isClosed())) {
						connection.close();
					}
				} catch (SQLException e) {
					getLog().error("Error while closing connection: " + e.getMessage());
				}
			}
		} catch (Exception e) {
			getLog().error("Error while creating ClassLoader!: " + e.getMessage(), e);
			throw new MojoExecutionException(e.getMessage());
		} finally {
			Thread.currentThread().setContextClassLoader(contextClassLoader);
		}
	}

	protected Connection loadConnection(Properties properties, Target target)
			throws MojoExecutionException {
		Connection connection = null;
		String driverClassName = properties.getProperty(Environment.DRIVER);
		try {
			if ((target == Target.EXPORT) || (target == Target.BOTH)) {
				Class<?> driverClass = Thread.currentThread().getContextClassLoader()
						.loadClass(driverClassName);

				getLog().debug("Registering JDBC-driver " + driverClass.getName());
				DriverManager.registerDriver((Driver) driverClass.newInstance());
				String datasourceUrl = properties.getProperty(Environment.URL);
				String datasourceUser = properties.getProperty(Environment.USER);
				String datasourcePass = properties.getProperty(Environment.PASS);
				getLog().debug(
						"Opening JDBC-connection to " + datasourceUrl + " as " + datasourceUser
								+ " with password " + datasourcePass);

				connection = DriverManager.getConnection(datasourceUrl, datasourceUser, datasourcePass);
			}
		} catch (ClassNotFoundException e) {
			getLog().error("Dependency for driver-class " + driverClassName + " is missing!");
			throw new MojoExecutionException(e.getMessage());
		} catch (Exception e) {
			getLog().error("Cannot establish connection to database!");
			Enumeration<Driver> drivers = DriverManager.getDrivers();
			if (!drivers.hasMoreElements()) {
				getLog().error("No drivers registered!");
			}
			while (drivers.hasMoreElements()) {
				getLog().debug("Driver: " + drivers.nextElement());
			}
			throw new MojoExecutionException(e.getMessage());
		}
		return connection;
	}

	protected Properties loadHibernateProperties() throws MojoExecutionException {
		Properties properties = new Properties();
		try {
			File file = new File(this.hibernateProperties);
			if (file.exists()) {
				getLog().info("Reading properties from file " + this.hibernateProperties + "...");
				properties.load(new FileInputStream(file));
			} else {
				getLog().info(
						"No hibernate-properties-file found! (Checked path: " + this.hibernateProperties + ")");
			}
		} catch (IOException e) {
			getLog().error("Error while reading properties!", e);
			throw new MojoExecutionException(e.getMessage());
		}
		return properties;
	}

	protected boolean matchesEntityTypeFilter(MetadataReader reader,
			MetadataReaderFactory readerFactory) throws IOException {
		for (TypeFilter filter : ENTITY_TYPE_FILTERS) {
			if (filter.match(reader, readerFactory)) {
				return true;
			}
		}
		return false;
	}

	protected void overwriteProperties(Properties properties) throws MojoFailureException {
		if (this.driver != null) {
			String existingDriver = properties.getProperty(Environment.DRIVER);
			if (existingDriver != null) {
				getLog().info(
						"Overwriting property + " + Environment.DRIVER + "=" + existingDriver
								+ " with the value " + this.driver);
			} else {
				getLog().debug("Using the value " + this.driver);
			}
			properties.setProperty(Environment.DRIVER, this.driver);
		}
		if (this.url != null) {
			String existingUrl = properties.getProperty(Environment.URL);
			if (existingUrl != null) {
				getLog().debug(
						"Overwriting property " + Environment.URL + "=" + existingUrl + " with the value "
								+ this.url);
			} else {
				getLog().debug("Using the value " + this.url);
			}
			properties.setProperty(Environment.URL, this.url);
		}
		if (this.user != null) {
			String existingUser = properties.getProperty(Environment.USER);
			if (existingUser != null) {
				getLog().debug(
						"Overwriting property " + Environment.USER + "=" + existingUser + " with the value "
								+ this.user);
			} else {
				getLog().debug("Using the value " + this.user);
			}
			properties.setProperty(Environment.USER, this.user);
		}
		if (this.password != null) {
			String existingPassword = properties.getProperty(Environment.PASS);
			if (existingPassword != null) {
				getLog().debug(
						"Overwriting property " + Environment.PASS + "=" + existingPassword
								+ " with the value " + this.password);
			} else {
				getLog().debug("Using the value " + this.password);
			}
			properties.setProperty(Environment.PASS, this.password);
		}
		if (this.dialect != null) {
			String existingDialect = properties.getProperty(Environment.DIALECT);
			if (existingDialect != null) {
				getLog().debug(
						"Overwriting property " + Environment.DIALECT + "=" + existingDialect
								+ " with the value " + this.dialect);
			} else {
				getLog().debug("Using the value " + this.dialect);
			}
			properties.setProperty(Environment.DIALECT, this.dialect);
		}
		if (properties.isEmpty()) {
			getLog().error("No properties set!");
			throw new MojoFailureException("Hibernate-Configuration is missing!");
		}
	}

	protected void registerAnnotatedClass(Configuration cfg, Set<String> packages,
			Set<Class<?>> classes) {
		getLog().debug("Registering package to hibernate configuration");
		if (!CollectionUtils.isEmpty(packages)) {
			for (String packageName : packages) {
				getLog().debug("- package " + packageName);
				cfg.addPackage(packageName);
			}
		}
		getLog().debug("Adding annotated classes to hibernate configuration");
		if (!CollectionUtils.isEmpty(classes)) {
			for (Class<?> clazz : classes) {
				getLog().debug("- class " + clazz.getName());
				cfg.addAnnotatedClass(clazz);
			}
		}
	}

	protected void scanAnnotatedClass(String[] packageToScan, Set<Class<?>> classSet,
			Set<String> packageSet) throws IOException, ClassNotFoundException {
		ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
		ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver(
				classLoader);
		for (String pkg : packageToScan) {
			String pattern = "classpath*:" + ClassUtils.convertClassNameToResourcePath(pkg)
					+ "/**/*.class";

			Resource[] resources = resourcePatternResolver.getResources(pattern);
			MetadataReaderFactory readerFactory = new CachingMetadataReaderFactory(
					resourcePatternResolver);
			if (resources != null) {
				for (Resource resource : resources) {
					if (resource.isReadable()) {
						MetadataReader reader = readerFactory.getMetadataReader(resource);
						String className = reader.getClassMetadata().getClassName();
						if (matchesEntityTypeFilter(reader, readerFactory)) {
							Class<?> loadClass = resourcePatternResolver.getClassLoader().loadClass(className);
							classSet.add(loadClass);
						} else if (className.endsWith(PACKAGE_INFO_SUFFIX)) {
							packageSet.add(className.substring(0, className.length() - ".package-info".length()));
						}
					}
				}
			}
		}
	}
}
